Stack Memory

How does it work

Reserving Space

  • At the lowest level, there is no “array construct” at all. There are only:

    • a stack pointer adjustment

    • address arithmetic

  • Everything else is syntax layered on top.

  • What “reserving space” really is:

    • sub rsp, 32

  • The stack pointer ( rsp ) just moves down, and that region is now “owned”.

  • Example:

    MyStruct :: struct {
        a: u32,
        data: [16]u8,
        b: u64,
    }
    sizeof(MyStruct) = 32
    offset(data) = 4
    
    foo:
        sub     rsp, 32        ; reserve entire struct
        ; x lives at [rsp + 0]
    
        ; write x.a = 1
        mov     dword ptr [rsp + 0], 1
    
        ; write x.data[3] = 0xFF
        mov     byte ptr [rsp + 4 + 3], 0xFF
    
        ; write x.b = 42
        mov     qword ptr [rsp + 24], 42
    
        add     rsp, 32
        ret
    
    • The compiler lowers:

      • data: [16]u8

    • into:

      • +16 bytes at offset 4

    • After lowering, the concept of “array” is gone.

    • Only this remains: base_pointer + constant_offset .

    • The:

      • x.data[i]

    • Becomes:

      • mov byte ptr [base + 4 + i], ...

Stack Probing

  • If the struct (and thus array) is large (e.g. > 4KB), compilers may emit stack probing:

sub rsp, 4096
mov [rsp], eax       ; touch page

sub rsp, 4096
mov [rsp], eax
  • This prevents skipping guard pages (stack overflow protection).

  • The array itself still doesn’t exist as a concept—only the total size matters.

Scoped Stack Allocation

  • Scoped stack allocation (like alloca)
    Introduce something like:
    sb := stack_buffer(8)
    Rules:
    only valid in current scope
    cannot escape (compiler enforced)
    This is similar to:

  • LLVM alloca

  • C alloca  (but unsafe)

  • It provides dynamic stack allocation → pointer to memory

  • The core problem is no ownership tracking → easy to create dangling pointers

  • Example:

    make_string_buffer :: proc(cap: uint) -> String_Buffer {
        ptr := stack_alloc(cap)
        return { ptr, cap, 0 }
    }
    
    sb := make_string_buffer(8)
    
    • If sb  escapes the scope where stack_alloc happened:

    return sb  // BUG
    
    • buf  points to invalid stack memory

    • This is why languages don’t expose raw “stack allocation returning pointers” freely.

Syntaxes Ideas for allocating stack memory

  • data: u8 * N

  • data: u8[N]

  • data: [N]u8

  • data: stack_alloc(N)

    • lowers to sub rsp, N

    • returns a ^u8  pointing to that memory

  • data: block(u8, N)

    • lowers to: sub rsp, N * sizeof(u8)

    • data is just a pointer to the first byte

How String Literals are stored

const char *s = "hello";
  • Is stored in the program’s read-only data section:

    • Known at compile time + immutable

    • .rodata

      • initialized at compile time

      • never modified at runtime

      • mapped read-only by OS

      • can be shared between processes

        • multiple programs using same library

        • OS maps same physical pages

        • This only works if memory is immutable.

      • Size:

        • There is no fixed or intrinsic “capacity” for .rodata.

        • Its size is determined entirely by the program and the platform constraints.

        • "whatever size the compiler/linker emits, limited by the system"

        • Real limits:

          • 32-bit systems

            • Typical virtual address space: ~2–4 GB total.

            • So .rodata  must fit within that along with everything else.

            • Large .rodata  (hundreds of MB) can become a problem.

          • 64-bit systems

            • Typical virtual address space: terabytes+.

            • .rodata  is effectively limited by:

              • available RAM

              • OS limits

            • linker/toolchain constraints

            • Practically:

              • tens or hundreds of MB → fine

              • multi-GB → possible but unusual

    .rodata   (ELF / Unix)
    .rdata    (Windows)
    __TEXT,__cstring (Mach-O / macOS)
    
    section .rodata
    .Lstr:
        db 'h','e','l','l','o',0
    
    section .text
        lea rax, [rel .Lstr]   ; pointer to string
    
  • Static lifetime (exists for entire program execution)

  • Immutable (writing to it is undefined behavior)

    char *s = "hello";
    s[0] = 'H'; // undefined behavior, because memory is in read-only segment.
    
  • Usually deduplicated by the compiler/linker

    char *a = "hello";
    char *b = "hello";
    
    • a  â†’ same address as b . Compilers often merge them (string interning at compile time).

  • In C:

    • Length is not intrinsic.

      • strlen(s)  must scan memory

      • O(n) operation.

Size

  • The stack does have a fixed (or bounded) size, unlike .rodata, and that’s why large allocations can fail.

  • Common defaults

    • Linux: ~8 MB per thread

    • Windows: ~1 MB per thread (default)

  • Can be changed, but always finite

  • Why is it limited :

    • Every thread gets its own stack:

      • threads Ă— stack_size = total reserved memory

      • If stacks were large (e.g., 100 MB), creating many threads would explode memory usage.

    • Keep it fast:

      • Stack allocation is just:

        • sub rsp, N

      • No checks, no allocator.

      • the OS gives a fixed region

      • overflow is detected via guard pages

    • Guard pages:

      [ stack memory ][ guard page ]
      
      • If you cross it → segmentation fault

What to do if I want static memory for a critical safe software, but I'm getting blocked by the stack limits the OS gives me?
  • Stop using the stack for large storage and move that memory to a different region with controlled lifetime.

  • The stack is the wrong tool for this requirement.

  • Options :

    1. Static/global memory

      static uint8_t buffer[4 * 1024 * 1024];
      
      • Lives in .data  or .bss

      • Not limited by stack size

      • Lifetime = entire program

      • No allocation at runtime

      • When to use

        • fixed-size buffers

        • embedded / critical systems

        • known upper bounds

    2. Static Arena, managed by an allocator (no heap usage):

      static uint8_t buffer[4 * 1024 * 1024];
      typedef struct {
          uint8_t *ptr;
          size_t   size;
          size_t   used;
      } Arena;
      
      • deterministic

      • no heap fragmentation

      • no OS allocation after startup

      • flexible usage

      • This is the most common solution in:

        • game engines

        • embedded systems

        • safety-critical code

    3. Heap, but preallocated once.

      void *mem = malloc(4 * 1024 * 1024);
      
      • Do this at startup only

      • treat it as a static pool

      • large capacity

      • avoids stack limits

      • still deterministic if controlled

    4. Increase stack size:

      • Linux: ulimit -s

      • Windows: linker flag /STACK

      • still per-thread

      • still fragile

      • still easy to overflow

    • Comparisons :

      • Cache locality:

        • From a cache locality perspective, A (static), B (arena over static), and C (heap preallocated once) are almost identical if they end up as one contiguous block.

        • The differences are subtle and mostly about control and predictability, not raw cache behavior.

        • The cache doesn't care whether memory came from:

          • stack

          • heap

          • .data / .bss

        • Once mapped, it’s just virtual memory.

      • Determinism:

        • (1)/(2): fixed address range

        • (3): chosen by allocator (less predictable)

      • Fragmentation

        • (1)/(2): none

        • (3): none if single allocation

      • Startup cost

        • (1)/(2): zero

        • (3): one allocation call

      • Binary size

        • (1)/(2)

          • If .bss  (zero-initialized)

            static uint8_t buffer[4 * 1024 * 1024];
            
            • Binary size: ~0 bytes added (only metadata)

            • The binary just says: "reserve X bytes of zeroed memory"

          • If .data  (initialized)

            static uint8_t buffer[4 * 1024 * 1024] = {1,2,3,...};
            
            • Binary size: +4 MB in executable

        • (3)

          • Binary size: no increase

      • RAM Usage

        • (1)/(2)/(3)

          • virtual memory reserved: 4 MB

          • physical memory used:    0 initially

          • Pages are mapped lazily when accessed.

          • file-backed pages

          • loaded on demand